fix(labeler): Access custom-group roles + PDS-fetch SSRF guard (review round 1)#2113
Conversation
|
Scope checkThis PR changes 551 lines across 7 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | ca40128 | Jul 18 2026, 05:17 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | ca40128 | Jul 18 2026, 05:17 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | ca40128 | Jul 18 2026, 05:17 PM |
There was a problem hiding this comment.
This PR fixes two real security issues in the labeler: Access group mapping was reading from the wrong JWT claim, and the PDS CAR fetch had no SSRF guard or body deadline. The approach is sound and fits EmDash's architecture: it reuses the shared emdash/security/ssrf resolver/validator instead of duplicating IP logic, and it deliberately keeps the PDS-specific 404/5xx error mapping rather than routing through fetchVerifiedResource.
I verified:
- The new
custom.groupsextraction inaccess-auth.tsis strictly guarded (non-record/non-array/non-string shapes ignored). pds-verify.tsnow enforces HTTPS, per-hop DoH resolution against private/reserved IPs, manual redirect revalidation (max 3 hops), and a total wall-clock body deadline bounded bytimeoutMs.discovery-consumer.tsandrecord-verification.tsthreadresolveHostnamethrough both the create and delete paths.- The
PDS_HOST_BLOCKEDdead-letter reason is wired into the consumer's switch and thedead_letters.reasoncolumn isTEXTwith no CHECK constraint, so the new value is safe. - No published package is modified; the changeset n/a is correct.
One real correctness gap remains: the implementation allows service tokens to inherit roles from payload.custom.groups if a group name happens to match an admin/reviewer allowlist entry, while the added test explicitly claims "a stray custom object must neither reclassify the identity nor add roles its common_name did not earn." Enforcing that invariant is a one-line change.
| const principal = identity.kind === "service" ? identity.commonName : identity.email; | ||
| const principals = new Set<string>([principal, ...groupPrincipals(payload.groups)]); | ||
| const principals = new Set<string>([principal, ...groupPrincipals(payload.custom)]); |
There was a problem hiding this comment.
[needs fixing] The new group extraction is applied to all identities, including service tokens. The added test comment says "Service tokens are identified by common_name and carry no IdP groups; a stray custom object must neither reclassify the identity nor add roles its common_name did not earn," but the code will in fact grant an admin/reviewer role to a service token if payload.custom.groups contains a matching allowlist entry. This violates the stated invariant and is the only fail-open path introduced by moving the group claim under custom.
Restrict group principals to human identities so service tokens can only be authorized by common_name:
| const principal = identity.kind === "service" ? identity.commonName : identity.email; | |
| const principals = new Set<string>([principal, ...groupPrincipals(payload.groups)]); | |
| const principals = new Set<string>([principal, ...groupPrincipals(payload.custom)]); | |
| const principal = identity.kind === "service" ? identity.commonName : identity.email; | |
| const principals = new Set<string>([ | |
| principal, | |
| ...(identity.kind === "human" ? groupPrincipals(payload.custom) : []), | |
| ]); |
There was a problem hiding this comment.
Fixed. Group principals are now gated to human identities — service tokens resolve roles from common_name only:
...(identity.kind === "human" ? groupPrincipals(payload.custom) : [])
Good catch that the code contradicted the test's stated invariant. The original service-token test was weak (it used a non-matching group, so it passed despite the bug); replaced with one asserting a service token whose custom.groups contains an allowlisted entry gets roles === [] — it fails against the pre-fix code (expected [ 'admin' ] to deeply equal []) and passes after the gate.
~ 🤖 Claude Fable 5
Cloudflare Access places IdP group membership inside the verified application token's `custom` object (a `groups` claim configured on the SAML/OIDC integration), never as a top-level `groups` claim. The role mapper read `payload.groups`, which Access never populates, so the group-only reviewer/admin allowlist granted no roles to human operators whose access is conferred by group. Read the `groups` array from the verified `payload.custom` object, guarding the `custom` record and the array shape strictly. Restrict group extraction to human identities: a service token is authorized solely by common_name and carries no IdP identity, so a `custom.groups` on a service token must never confer a role (moving the group claim under `custom` would otherwise open a fail-open path). Email/common_name resolution is otherwise unchanged.
The PDS endpoint is resolved from a publisher-controlled DID document, and the CAR fetch followed redirects with the default fetch policy and no scheme, DNS, or reserved-address checks. A hostile DID/PDS document could point the endpoint (or a redirect it serves) at a private/reserved address or a non-HTTPS host, turning the verifier into a blind SSRF probe. Validate every hop before fetching, sharing the DoH resolver (`cloudflareDohResolver`) that artifact acquisition uses and routing each hop through core's `resolveAndValidateExternalUrl`: HTTPS-only, DoH resolution, and private/reserved-IP rejection. Redirects are now followed manually (`redirect: "manual"`) so each `Location` is re-validated per-hop. A blocked hop rejects with the new permanent `PDS_HOST_BLOCKED` reason (dead-lettered, never retried). This shares the resolver but NOT artifact acquisition's address predicate: `fetchVerifiedResource`'s stricter `isForbiddenAddress` is not exported from @emdash-cms/registry-verification, so this path uses core's `isPrivateIp`, which blocks loopback, RFC1918, link-local/metadata, and IPv4-mapped/NAT64 forms but not CGNAT (100.64.0.0/10), benchmarking (198.18.0.0/15), or multicast/reserved (224.0.0.0/4, ff00::/8) — an accepted residual for PDS egress; closing it would mean exporting a published-package predicate. Also bound the body read by a wall-clock budget: the header-phase abort timer is cleared once headers arrive, so each streamed chunk is now raced against the remaining fetch timeout, preventing a slow-drip PDS from holding the read open indefinitely. The resolver is threaded through the discovery consumer's closure deps; the existing 404/5xx/size status classification the delete and retry paths depend on is preserved.
8abe61e to
5913738
Compare
The PDS SSRF egress guard mapped every `SsrfError` from
`resolveAndValidateExternalUrl` to the permanent `PDS_HOST_BLOCKED`
reason. But that helper raises `SsrfError` for two distinct cases: a
permanent private/reserved-address or scheme block, and a transient
resolver failure (DoH network error, SERVFAIL, timeout). A transient DoH
blip while resolving a publisher's PDS host therefore permanently
dead-lettered a legitimate record instead of retrying it.
Core exposes no structured discriminator (`SsrfError` carries a single
`code`), so key on the resolver-failure message prefix
("Could not resolve hostname:") to map that case to the transient
`PDS_NETWORK_ERROR` reason, which the consumer retries via `isTransient`.
Genuine address/scheme blocks stay `PDS_HOST_BLOCKED` (permanent). A test
pins the prefix so a core wording change fails loudly rather than
silently mis-classifying.
|
Follow-up ~ 🤖 Claude Fable 5 |
ascorbic
left a comment
There was a problem hiding this comment.
Changes are still needed before merge. The latest commit correctly retries resolver failures that throw, but several blockers remain.
apps/labeler/src/pds-verify.ts:163-209validates a DoH result and then performs a separate hostname fetch. With noglobal_fetch_strictly_publicinapps/labeler/wrangler.jsonc, same-zone routing and DNS rebinding can bypass the preflight check.apps/labeler/src/access-auth.ts:112-121rejects an emptysubbefore consideringcommon_name. Real Cloudflare Access service-token JWTs usesub: "", so the service identity path remains unreachable; the test uses a synthetic human-style subject.- Empty DNS answers are not covered by the new prefix classifier. NXDOMAIN/NODATA becomes
Hostname resolved to no addresses, maps to permanentPDS_HOST_BLOCKED, and is immediately dead-lettered bydiscovery-consumer.ts:302-318. Temporary negative answers during DNS propagation therefore lose records. - The shared address predicate still permits CGNAT, benchmarking, multicast/reserved IPv4, IPv6 unspecified, and IPv6 multicast ranges.
- Human authorization still depends solely on truncatable Access
custom.groupsclaims.
Thrown DoH network/HTTP/malformed/SERVFAIL/REFUSED failures are fixed by this delta; the empty-answer path is not.
~ Sol 🤖
|
Addressing the two true blockers as follow-up commits: the service-token The remaining items are deferred to tracked follow-ups per the maintainer's scope call for this round (correctness/security blockers now; platform/core hardening separately): ~ 🤖 Claude Fable 5 |
… tokens Cloudflare Access issues non-identity (service-token) application JWTs with `sub: ""` and identifies the token via `common_name` (the CF-Access-Client-Id). The verifier rejected any empty `sub` before it reached the common_name branch, so every real service-token request failed `invalid-token` and the service-identity path was unreachable in production — the prior service-token tests passed only because they minted a synthetic non-empty subject. Require `sub` to be a string, but only reject an empty `sub` on the human/email path; the service path (common_name present) now proceeds with the empty subject Access actually sends. Tests use the realistic service-token shape (empty sub + common_name), and a human token with an empty sub is still rejected.
The PDS SSRF guard mapped every non-resolver-failure `SsrfError` to the
permanent `PDS_HOST_BLOCKED` reason. But `resolveAndValidateExternalUrl`
also raises `SsrfError` ("Hostname resolved to no addresses") when the
resolver returns an empty answer (NXDOMAIN / NODATA), which a host
mid-DNS-propagation produces transiently — so a legitimate record was
permanently dead-lettered on a temporary negative answer.
Classify the empty-answer message as the transient `PDS_NETWORK_ERROR`
too (the consumer retries it via `isTransient`; a genuinely absent host
dead-letters through retry exhaustion). Keyed on a pinned message constant
like the resolver-failure prefix, so a core wording change fails a test.
Genuine private/reserved-address and scheme blocks stay permanent.
|
Both fixed as follow-up commits:
Labeler suite 1025 green. The deferred items ( ~ 🤖 Claude Fable 5 |
ascorbic
left a comment
There was a problem hiding this comment.
The service-token and empty-answer follow-ups are correct: realistic common_name tokens with sub: "" now reach the service path without granting human group roles, and NXDOMAIN/NODATA reaches the transient queue retry path. One in-scope reliability gap remains in that new retry behavior.
apps/labeler/src/discovery-consumer.ts:303-305 calls retry() without delaySeconds, and the queue consumer has no retry_delay. Cloudflare Queues therefore marks the message for the next batch immediately. With only five retries, ordinary DNS propagation can exhaust every attempt before records appear, after which the DLQ path records UNEXPECTED_ERROR. Please add a meaningful retry delay for transient DNS resolution failures.
The SSRF predicate/platform and Access custom-claim hardening items are explicitly deferred by the maintainer and are not repeated here as blockers.
~ Sol 🤖
The records queue re-delivers immediately (no `retry_delay`), so classifying an
empty DNS answer / resolver outage as transient only helped if the retry waited
for propagation — otherwise all `max_retries` (5) burn in seconds and the record
DLQs as UNEXPECTED_ERROR before DNS settles. `PdsVerificationError` now carries
an optional `retryAfterSeconds`, set (to 60s) only on the transient DNS-resolution
path; the consumer passes it as `retry({ delaySeconds })`. Other transient
retries (network, 5xx, timeout) stay immediate.
Delay strategy: fixed 60s. With max_retries: 5 that spreads retries across a
~5-minute propagation window. Chosen for reconciliation with labeler #2113.
…omplete The discovery consumer retried transient PDS failures with a bare `retry()`, and the queue consumer has no `retry_delay`, so Cloudflare Queues re-delivered immediately in the next batch. With only max_retries attempts, ordinary DNS propagation (now routed here via the empty-answer / resolver-failure transient classification) could exhaust every attempt before the host resolved — after which the record dead-lettered as UNEXPECTED_ERROR. Pass a capped exponential `delaySeconds` (15s, doubling per delivery attempt, capped at 300s) when retrying a transient PDS failure, giving propagation time without an unbounded backlog. The delay applies only to the transient PDS retry path; permanent failures still dead-letter immediately with no retry, and the other retry paths (label issuance, workflow dispatch, DID resolution) are unchanged.
|
Retry-delay fixed in Tests: the transient retry asserts (The aggregator #2117 uses a fixed 60s carried on the error rather than attempt-scaled — its classification happens where ~ 🤖 Claude Fable 5 |
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
ascorbic
left a comment
There was a problem hiding this comment.
Follow-up reviewed. ca40128b applies capped exponential backoff to exactly the transient PDS-resolution branch using the one-based delivery attempt count: 15, 30, 60, 120, then 240 seconds under the configured retry budget. Permanent PDS failures and unrelated retry paths remain unchanged, and the tests exercise increasing delivery attempts.
The posted retry-delay blocker is resolved. I found no remaining in-scope issue.
~ Sol 🤖
3692bbf
into
feat/plugin-registry-labelling-service
…nst SSRF (review round 1) (#2117) * fix(aggregator): wrap unexpected XRPC errors and harden PDS egress against SSRF Finding 17 (Sol): a non-XRPC failure resolving the labeler policy (e.g. a D1 error) escaped `handleXrpc` unwrapped, hitting workerd's bare 500 without the CORS or `private, no-store` headers every aggregator response carries. Convert unexpected errors to a generic 500 through the same wrapper, logging the internal detail but never leaking it to the client. XRPCError handling is unchanged. SSRF follow-up: `pds-verify.ts` fetched the publisher-controlled PDS endpoint (from a DID document) following redirects with no scheme/DNS/reserved-address validation and no body-read deadline. Route it through the shared hardened egress (`resolveAndValidateExternalUrl` + injected `cloudflareDohResolver`): HTTPS-only, per-hop re-resolution under `redirect: "manual"` with a bounded redirect count, a wall-clock deadline spanning redirects and the body read, and fail-closed when no resolver is injected. Blocks are reported as a new permanent `PDS_ADDRESS_BLOCKED` reason; existing 404/5xx/too-large/invalid-proof and the transient network classification are preserved. * fix(aggregator): retry PDS verification on resolver-infra failures, don't dead-letter `assertFetchableUrl` collapsed every `SsrfError` from `resolveAndValidateExternalUrl` into the permanent `PDS_ADDRESS_BLOCKED`, but that helper also throws `SsrfError` when the injected DNS resolver itself fails (DoH network error / SERVFAIL / timeout) — transient infrastructure, not a disallowed publisher address. A DoH blip therefore permanently dead-lettered legitimate records and risked mass dead-lettering during a resolver outage. `SsrfError.code` is the same constant for both cases, so key on the message prefix the helper uses only for the resolver-threw path (`Could not resolve hostname:`): map that to the transient `PDS_NETWORK_ERROR` (retried), and keep genuine address blocks — private/reserved IP, blocked host, scheme — as the permanent `PDS_ADDRESS_BLOCKED`. A test asserts the transient mapping so a future wording change in the shared core helper breaks loudly rather than silently mis-classifying. Also adds a redirect-hop-limit boundary test. * fix(aggregator): retry PDS verification on empty DNS answer, don't dead-letter An empty resolver answer (NXDOMAIN / NOERROR-NODATA / CNAME-only) makes `resolveAndValidateExternalUrl` throw `SsrfError("Hostname resolved to no addresses")`, which the classifier mapped to the permanent PDS_ADDRESS_BLOCKED — so a host mid-DNS-propagation lost the record to a dead-letter. Treat the empty-answer message as a transient resolution failure (PDS_NETWORK_ERROR, retried) alongside the existing resolver-threw case; a genuinely-gone host still dead-letters via retry exhaustion. Genuine private/reserved/blocked-host addresses stay permanent. Keyed on the shared helper's message as a module constant with a test, matching the resolver-failure-prefix approach. * fix(aggregator): delay re-delivery on transient DNS-resolution failure The records queue re-delivers immediately (no `retry_delay`), so classifying an empty DNS answer / resolver outage as transient only helped if the retry waited for propagation — otherwise all `max_retries` (5) burn in seconds and the record DLQs as UNEXPECTED_ERROR before DNS settles. `PdsVerificationError` now carries an optional `retryAfterSeconds`, set (to 60s) only on the transient DNS-resolution path; the consumer passes it as `retry({ delaySeconds })`. Other transient retries (network, 5xx, timeout) stay immediate. Delay strategy: fixed 60s. With max_retries: 5 that spreads retries across a ~5-minute propagation window. Chosen for reconciliation with labeler #2113.
After merging the integration branch (which added #2113's transient-DNS retry backoff), the applyDiscoveryDelete docstring still said a failed negation propagates to classifyDiscoveryError. Since the Blocker-2 fix, the delete mutation phase has its own always-retry catch — update the comment to match. No behavior change.
…w round 1) (#2115) * fix(labeler): redrive the subscription-DO notify on console mutation replay A transient afterCommit failure after a label commits leaves the row publication_pending=1 and drops the live broadcast; the guard replay branch did not re-drive the notify, so a client retry never recovered it. The label-issue and emergency replay branches now re-fire the post-commit notify (keyed on the committed action), giving retries a cheap path back to a live broadcast. The reconciliation publication-pending sweep remains the durable backstop. Refs Sol finding 7. * fix(labeler): re-sign retired-key labels on WebSocket subscription replay After a routine key rotation the DID document publishes only the new key, but retained rows keep their old-key signatures; queryLabels re-signs lazily on read while the subscribeLabels replay path sent rows unchanged, so a fresh aggregator rejected the first old-key frame and could not advance. The replay reader now reuses queryLabels' lazy re-sign helper (exported from query-labels), bringing stale-key rows onto the active key before framing and persisting the result so the work is not repeated per connection. Sequence and ordering are preserved; a signing pause throws rather than serving an unverifiable frame. Refs Sol finding 5. * fix(labeler): publish automated assessment labels live, with a reconciliation backstop Automated pending/outcome/block/negation labels committed without ever notifying the subscription DO, so a connected aggregator received nothing until it reconnected or an unrelated notification arrived. The orchestrator now issues finalization labels publication_pending=1 and broadcasts each to the same subscription-DO publisher the console path uses, off the commit. A new publication-pending sweep in the reconciliation cron re-drives any notify that was dropped (assessment or console path alike) — the durable guarantee that a stranded row reaches subscribers and stops blocking the next key rotation. Refs Sol findings 4 and 7. * fix(labeler): guard the finalization CAS on signing state so a rotation pause can't strand a run If signing paused between finalization prep and the batch commit, the guarded label INSERTs no-oped while the unguarded assessment CAS still committed terminal state — leaving the run terminal with its pending-negation, outcome, and block labels missing, and the Workflow retry seeing terminal and never repairing them. The CAS now shares the same signing-state predicate as the issuance statements, so the whole db.batch is all-or-nothing: a mid-batch pause no-ops the CAS too, the run stays running, and the retry re-runs finalization after signing resumes. Refs Sol finding 6. * perf(labeler): index issued_labels for the publication-pending sweep The reconciliation sweep filtered issued_labels by publication_pending ordered by sequence with no covering index, full-scanning a monotonically growing table every cron tick — a D1 query-timeout there would strand pending rows and block the very rotation drain the sweep exists to unblock. Adds migration 0011 with a partial covering index on (sequence, cts) WHERE publication_pending = 1, and a query-plan guard so the sweep can't silently regress to a full scan. Follow-up to emdashbot review on #2115. * docs(labeler): drop the stale mid-batch signing-flip gap from finalize The F6 fix guards the finalization CAS on the same signing-state predicate as the label issuances, so the race-analysis comment's first bullet (claiming the CAS is unguarded and a flip could commit terminal state with labels suppressed) is no longer true. Replaces it with the now-closed description and keeps the two genuinely-remaining narrower gaps. Follow-up to emdashbot review on #2115. * fix(labeler): publish discovery-consumer labels live, with the sweep backstop The #4 fix gave the orchestrator path live publication but missed the discovery-consumer path: its initial assessment-pending issuance and its deletion negations committed publication_pending=0 with no subscription-DO broadcast, so connected subscribers never received them and the new reconciliation sweep (pending=1 only) could not recover them. Both issuances now take the same subscription-DO publisher the orchestrator uses — publication_pending=1 on commit plus a best-effort post-commit notify (a dropped broadcast never fails the discovery message; the sweep re-drives it). The rotation-drain contract is unchanged: these pending rows drain exactly like the orchestrator's. Follow-up to Sol review on #2115. * fix(labeler): negate a deleted run's pending label before cancelling it The delete path cancelled pending/running runs to terminal 'cancelled' before issuing their assessment-pending negations. If issuance was paused (signing rotation) the negation threw and the message retried, but on redelivery listNonTerminalAssessmentsForUri excluded the already-cancelled runs — so there was nothing left to negate, the message acked, and the active assessment-pending label survived a deleted release forever. Each run's negation is now issued before its terminal cancellation, so a paused/failed negation leaves the run non-terminal and re-discoverable on redelivery; a run is retired only after its negation commits, and the message cannot ack while any pending/running run is un-negated. No active assessment-pending survives an acked delete. Follow-up to Sol review on #2115. * fix(labeler): guard the finalization CAS on the subject not being tombstoned Finalization's isSubjectCurrent re-check is a separate read from its commit CAS, a TOCTOU: a delete that tombstoned the subject in between still let the CAS commit 'running -> outcome' plus positive outcome/block labels, then the delete's own cancel CAS conflicted and was swallowed — leaving live block labels on a deleted release the evaluator would still honor. The finalization CAS now carries a not-deleted predicate on the run's subject (same all-or-nothing batch idiom as the F6 signing guard), so a tombstone landing before the batch no-ops the CAS and every label gated on the outcome state; finalization retries and stales the run out, never labelling a deleted subject. Follow-up to Sol round-3 review on #2115. * fix(labeler): retry (never dead-letter+ack) a delete-path mutation failure The 'a negation throw prevents ack' invariant only held for recognized issuance-unavailable errors; an unexpected signing/D1 failure during the delete's tombstone/negate/cancel fell through to dead-letter+ack (the create-path policy), leaving the run non-terminal with its assessment-pending label live on a deleted subject forever. The delete handler now splits the absence-verification phase (classified like create: transient retries, forged/permanent dead-letters) from the mutation phase, which ALWAYS retries on failure so the label can never be left live on an acked delete. A genuinely permanent fault exhausts to the DLQ via max_retries — acceptable versus acking a stranded label. Follow-up to Sol round-3 review on #2115. * docs(labeler): correct the delete-mutation retry reference post-merge After merging the integration branch (which added #2113's transient-DNS retry backoff), the applyDiscoveryDelete docstring still said a failed negation propagates to classifyDiscoveryError. Since the Blocker-2 fix, the delete mutation phase has its own always-retry catch — update the comment to match. No behavior change. * fix(labeler): guard the initial pending-label issuance against a concurrent delete The create path committed a recreated run as pending and THEN issued its positive assessment-pending with no commit-time guard. A concurrent delete could, in the gap, tombstone the subject, negate + cancel the (still-positive-less) run, and ack; the create then committed its positive at a higher sequence, winning label reduction — a live pending label on a tombstoned, cancelled subject. (Queue invocations overlap; max_concurrency is unset.) The initial issuance now goes through buildIssuanceStatements gated atomically on BOTH the run still being pending AND the exact (uri,cid) subject still undeleted (the same guard idiom as Blocker 1's finalization CAS). On a guard miss the label no-ops: the issuance is obsolete, so the create neither publishes nor dispatches a Workflow for a label that never committed; a non-persist not explained by the guard (a signing flip) still retries. With this and Blocker 1, every automated- positive issuance site in the discovery consumer is now delete-guarded (the delete-path negation is negative and exempt). Follow-up to Sol round-4 review on #2115. * fix(labeler): guard the operator-rerun pending label against a concurrent delete The operator rerun issued its positive assessment-pending with no commit-time guard and while its run was still 'observed' (advance to 'pending' is deferred), opening two integrity races with a concurrent discovery-delete (queue + HTTP overlap; max_concurrency unset): (a) ordering: the rerun commits its run+pending after a delete tombstoned and snapshotted the non-terminal runs, so the delete never negates it and the positive survives on the tombstoned subject; (b) observed gap: the delete-path negation only fired for pending/running runs, so a delete that saw the rerun's 'observed' run cancelled it WITHOUT negating its already-live positive. Fix (a): gate the rerun's assessment-pending issuance on requireSubjectNotDeleted + requireAssessmentState (threaded through prepareAutomatedLabelIssuance); on a miss the label no-ops, assertIssuancePersisted aborts before the deferred tail, so nothing is published, dispatched, or advanced. Fix (b): the delete-path negation now fires for any non-terminal run that committed a positive pending (keyed on the committed positive, not lifecycle state) — no dangling negations, no interleaving where a cancelled/deleted run keeps a live positive. With Blocker 1 and the round-4 discovery guard, every positive assessment-pending issuance across the discovery and console paths is now delete-safe. Follow-up to adversary review on #2115. * fix(labeler): structural delete-generation guard closing the create/verify/rerun-vs-delete race class Adds a monotonic delete_generation to subjects (migration 0013). Every delete increments it; create/verify/rerun capture it before reading state/verifying and CAS-guard their subject-undelete, run creation, and label issuance on the generation not having advanced. A decision made before a delete is rejected obsolete; one that captured the post-delete generation still works (republish). Closes three race seams: (1) createSubject un-delete is generation-gated so a stale verify can't resurrect a deleted subject; (2) the delete cleanup now scans pending-bearing runs INCLUDING terminal stale and negates any committed positive; (3) the console rerun's run creation is generation-gated so no orphan observed run. Implementation checkpoint; barrier tests for each seam + a republish test follow. Refs Sol round-5 (systemic close). * test(labeler): barrier tests for the delete-generation close (three seams + republish) Seam 1: a stale verify (barrier during verify, full delete completes) cannot resurrect the tombstoned subject — createSubject's generation-guarded undelete no-ops, no run, no positive, no dispatch. Seam 2: the delete negates a terminal stale run's stranded positive (widened scan). Seam 3: a rerun after a concurrent tombstone leaves no orphan observed operator run. Plus a delete-then-republish test proving a new revision after a delete assesses cleanly (the generation does not over-block). Each verified failing pre-fix. Refs Sol round-5 (systemic close).
What does this PR do?
Fixes two high-severity security findings from the adversarial review of umbrella #1909 (RFC #694), one commit each. Both TDD'd and passed an independent adversarial review plus a delta re-review of the amendments.
1.
fix(labeler): read operator group membership from the Access token'scustomobject. Role mapping read a top-levelgroupsclaim from the Cloudflare Access JWT — a claim Access never populates (the application-token claim set isaud/email/exp/iat/nbf/iss/type/identity_nonce/sub/country/custom; IdP group membership arrives undercustom). With a group-only reviewer/admin mapping, every human operator got zero roles — fail-closed lockout, but the console would be unusable in production. Now reads a strictly-guardedgroupsarray from the verifiedcustomobject (malformed shapes ignored; service-token path unchanged; a straycustom.groupson a service token grants nothing).Operational note for the runbooks (follow-up docs touch): the Access application's group claim must be named exactly
groups; a differently-named claim fails closed to zero roles.2.
fix(labeler): SSRF-guard the PDS record fetch. The PDS endpoint comes from a publisher-controlled DID document, andfetchCarfetched it with default redirect-following and no scheme/DNS/address validation — a blind request primitive onto internal/reserved addresses via a hostile DID or PDS redirect. Now: HTTPS-only, every hop (initial + each redirect,redirect: "manual", max 3) is resolved throughcloudflareDohResolverand validated (loopback, RFC1918, link-local/metadata, IPv4-mapped/NAT64 all rejected; fails closed if the resolver is omitted), and the body read is bounded by an absolute wall-clock deadline spanning header + streaming phases (slow-drip PDS → transient error → bounded queue retries, instead of an unbounded hang). A new permanentPDS_HOST_BLOCKEDdead-letter reason is threaded through the discovery consumer; the 404→RECORD_NOT_FOUND(delete-confirmation) and 5xx→transient-retry classifications are preserved exactly — deliberately not routed throughfetchVerifiedResource, which would collapse them.Documented residual (adversary-verified): the shared core predicate doesn't cover a few exotic ranges (CGNAT, benchmarking, multicast) that artifact acquisition's private predicate does; all high-value targets are covered by both.
Known sibling gaps tracked for follow-up (pre-existing, out of scope here): the
did:webDID-document fetch and the aggregator's ownpds-verify.tsremain unguarded — same hardening idiom, separate PR.Targets the
feat/plugin-registry-labelling-serviceintegration branch. Part of #1909; addresses two review findings there.Type of change
Checklist
pnpm typecheckpasses (all three labeler projects)pnpm lintpasses (type-aware, 0 diagnostics)pnpm testpasses (labeler: 848 + 136 + 39 = 1023) — TDD: the auth tests fail pre-fix ([]vs expected roles), the six SSRF cases fail pre-fix (fetches proceeded), the slow-drip test fails pre-fix (unbounded hang)pnpm formathas been run@emdash-cms/labeleris private; no published package touched.AI-generated code disclosure
Screenshots / test output
Try this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
fix/labeler-sol-r1-auth-ssrf. Updated automatically when the playground redeploys.